<?php
// Copyright 2019 Hackware SpA <human@hackware.cl>
// "Hackware Web Services Core" is released under the MIT License terms.

namespace Hawese\Core;

use Hawese\Core\Exceptions\ModelObjectNotFoundException;
use Hawese\Core\Exceptions\ModelValidationException;
use Hawese\Core\Exceptions\UnknownForeignObjectException;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;

use ArrayAccess;
use Exception;
use JsonSerializable;

/**
 * Base class for CRUD database models.
 *
 * You always need to define the static `$table` and `$attributes` props.
 *
 * - Supports find($pk), insert(), update() and delete() operations.
 * - select() will return a Laravel's Query Builder object.
 * - processCollection() will process each element of a Query Builder produced
 *   resultset with this class and allow you to `appendForeignObjects`.
 * - If you use the `created_at`, `updated_at` and/or `deleted_at` `$attributes`
 *   in your class declaration, it will automagically fill those fields.
 */
abstract class TableModel implements
    ArrayAccess,
    JsonSerializable,
    Arrayable,
    Jsonable
{
    public static $table;
    public static $attributes = []; // ['prop1' => ['validations'], ...]
    protected $appends = []; // Append JSONable attributes at runtime
    protected $hidden = ['deleted_at']; // Hide from serialization

    public static $primary_key = 'id';
    protected static $incrementing = true;
    public static $foreign_keys = []; // ['foreign_key' => Class::class, ...]

    protected $data = []; // Where $attributes values are saved
    protected $current_primary_key;

    /**
     * Bootstrapping
     * =============
     */

    /**
     * @param (array|object)[] $data Associative data with object attributes.
     */
    public function __construct($data = null)
    {
        $this->data = array_fill_keys(static::attributes(), null);

        if (isset($data)) {
            foreach ($data as $key => $value) {
                $this->data[$key] = $value;
            }
        }

        $this->current_primary_key = $this->data[static::$primary_key];
    }

    /**
     * Magic accesors.
     *
     * Custom getters must be camelCased and start with `get` followed by
     * `AttributeName`.
     *
     * ```php
     * protected function getCustomAttribute()
     * {
     *     return $this->data['custom_attribute'];
     * }
     * ```
     */
    public function __get(string $name)
    {
        $getter = 'get' . self::snakeToPascalCase($name);
        if (method_exists(static::class, $getter)) {
            return $this->{$getter}();
        }

        if (in_array($name, $this->instanceAttributes())) {
            return $this->data[$name] ?? null;
        }

        $trace = debug_backtrace();
        trigger_error(
            'Undefined property via __get(): ' . $name .
            ' in ' . $trace[0]['file'] .
            ' on line ' . $trace[0]['line'],
            E_USER_NOTICE
        );
        return null;
    }

    /**
     * Magic mutators.
     *
     * Custom setters must be camelCased and start with `set` followed by
     * `AttributeName`, `$new_value` must be assigned to the local
     * `$data['attribute_name']` store.
     *
     * ```php
     * protected function setCustomAttribute($new_value)
     * {
     *     $this->data['custom_attribute'] = $new_value;
     * }
     * ```
     */
    public function __set(string $name, $value)
    {
        $setter = 'set' . self::snakeToPascalCase($name);
        if (method_exists(static::class, $setter)) {
            return $this->{$setter}($value);
        }

        if (in_array($name, $this->instanceAttributes())) {
            return $this->data[$name] = $value;
        }

        $trace = debug_backtrace();
        trigger_error(
            'Undefined property via __set(): ' . $name .
            ' in ' . $trace[0]['file'] .
            ' on line ' . $trace[0]['line'],
            E_USER_NOTICE
        );
    }

    public function __isset(string $name)
    {
        return isset($this->data[$name]);
    }

    /**
     * Implements ArrayAccess.
     *
     * So you can ->pluck() on a collection or do these kind of nice things
     */
    public function offsetExists($offset)
    {
        return !!$this->{$offset};
    }

    public function offsetGet($offset)
    {
        return $this->{$offset};
    }
    
    public function offsetSet($offset, $value)
    {
        $this->{$offset} = $value;
    }
    
    public function offsetUnset($offset)
    {
        unset($this->data[$offset]);
    }

    /**
     * Implements toArray
     */
    public function toArray()
    {
        $data = [];

        // Process getters
        foreach ($this->instanceAttributes() as $attribute) {
            if ($this->{$attribute} instanceof Carbon) {
                // for $(crea|upda|dele)ted_at
                $data[$attribute] = $this->{$attribute}->format('c');
            } elseif ($this->{$attribute} instanceof Arrayable) {
                // for $foreign_keys
                $data[$attribute] = $this->{$attribute}->toArray();
            } else {
                $data[$attribute] = $this->{$attribute};
            }
        }

        // Forget hidden attributes
        // Not in toJson(), since in that context is not possible to determine
        // foreign object's hidden attributes.
        foreach ($this->hidden as $attribute) {
            unset($data[$attribute]);
        }

        return $data;
    }

    /**
     * Implements JsonSerializable
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * Implements Jsonable
     */
    public function toJson($options = 0)
    {
        return json_encode($this->jsonSerialize(), $options);
    }

    /**
     * Automagically use carbon on $(crea|upda|dele)ted_at
     */
    protected function dateSetter($attribute, $date) : void
    {
        if ($date instanceof Carbon || empty($date)) {
            $this->data[$attribute] = $date;
        } else {
            $this->data[$attribute] = new Carbon($date);
        }
    }

    public function setCreatedAt($value) : void
    {
        $this->dateSetter('created_at', $value);
    }
    
    public function setUpdatedAt($value) : void
    {
        $this->dateSetter('updated_at', $value);
    }
    
    public function setDeletedAt($value) : void
    {
        $this->dateSetter('deleted_at', $value);
    }

    /**
     * Return previously loaded relationship or load it now
     */
    protected function foreignObjectGetter($attribute)
    {
        if (array_key_exists($attribute, $this->data)) {
            return $this->data[$attribute];
        }

        $foreign_key = static::guessFK($attribute);
        return static::$foreign_keys[$foreign_key]::find($this->{$foreign_key});
    }

    /**
     * Main functionality
     * ==================
     */
    public static function select(?array $attributes = null) : Builder
    {
        if (!$attributes) {
            $attributes = static::attributes();
        }

        return app('db')
            ->table(static::$table)
            ->select(
                preg_filter('/^/', static::$table . '.', $attributes)
            );
    }

    /**
     * @params string $value checks against this value for equality.
     * @params string|array $fields field to compare, defaults to
     * static::$primary_key, if array is provided will try with any of
     * the provided fields.
     */
    public static function find(string $value, $fields = null) : self
    {
        $fields = $fields ?? static::$primary_key;
        if (!is_array($fields)) {
            $fields = [$fields];
        }

        $query = 'SELECT * FROM ' . static::$table . ' WHERE ';

        $query_fields = $fields;
        array_walk($query_fields, function (&$field) {
            $field = static::$table . ".$field = ? ";
        });
        $query .= implode(' OR ', $query_fields);

        if (in_array('deleted_at', static::attributes())) {
            $query .= 'AND deleted_at IS NULL';
        }

        $row = app('db')->selectOne(
            $query,
            array_fill(0, count($fields), $value)
        );

        if ($row) {
            return new static($row);
        }

        throw new ModelObjectNotFoundException(
            static::class,
            $fields,
            $value
        );
    }

    /**
     * @return mixed primary key value
     */
    public function insert()
    {
        $this->validate();

        $fields_to_insert = static::attributes();

        if (in_array('created_at', $this->instanceAttributes())) {
            $this->created_at = new Carbon();
        }

        $query = 'INSERT INTO ' . static::$table . ' (';
        $query .= implode(
            ',',
            preg_filter('/^/', static::$table . '.', $fields_to_insert)
        );
        $query .= ') VALUES (';
        $query .= trim(str_repeat('?,', count($fields_to_insert)), ',');
        $query .= ')';

        app('db')->insert(
            $query,
            array_map(
                function ($field) {
                    return $this->data[$field];
                },
                $fields_to_insert
            )
        );

        if (static::$incrementing) {
            $this->{static::$primary_key} = app('db')->getPdo()->lastInsertId();
        }

        return $this->{static::$primary_key};
    }

    public function update($fields = []) : bool
    {
        $this->validate();

        if (empty($fields)) {
            $fields = array_filter(
                static::attributes(),
                function ($attribute) {
                    return isset($this->data[$attribute]);
                }
            );
        }

        if (in_array('updated_at', $this->instanceAttributes())) {
            if (!in_array('updated_at', $fields)) {
                array_push($fields, 'updated_at');
            }
            $this->updated_at = new Carbon();
        }
        
        $query = 'UPDATE ' . static::$table . ' SET';
        $query .= trim(array_reduce(
            $fields,
            function ($carry, $item) {
                return $carry . ' ' . static::$table . ".$item=?,";
            },
            ''
        ), ',');
        $query .= ' WHERE ' . static::$primary_key . ' = ?';

        $operation = app('db')->update(
            $query,
            array_merge(
                array_map(
                    function ($field) {
                        return $this->data[$field];
                    },
                    $fields
                ),
                [$this->current_primary_key]
            )
        );

        if ($operation) {
            $this->current_primary_key = $this->{static::$primary_key};
        }

        return $operation;
    }

    public function delete() : bool
    {
        return static::staticDelete([$this->{static::$primary_key}]);
    }

    /**
     * Delete (potentially massively) based on primary key
     */
    public static function staticDelete(array $primary_keys): bool
    {
        $question_marks = self::commaStrRepeat('?', count($primary_keys));

        if (in_array('deleted_at', static::attributes())) {
            $deleted_at = new Carbon();

            return app('db')->update(
                'UPDATE ' . static::$table . ' SET' .
                ' deleted_at=? WHERE ' . static::$table . '.' .
                static::$primary_key . " IN ($question_marks)",
                array_merge([Carbon::now()], $primary_keys)
            );
        }

        return app('db')->delete(
            'DELETE FROM ' . static::$table .
            ' WHERE ' . static::$table . '.' . static::$primary_key .
            " IN ($question_marks)",
            $primary_keys
        );
    }
    
    public function validate()
    {
        try {
            return app('validator')
                ->make($this->data, static::$attributes)
                ->validate();
        } catch (ValidationException $e) {
            throw new ModelValidationException(
                static::class,
                $e->validator,
                $e->response,
                $e->errorBag
            );
        }
    }

    /**
     * Convert QueryBuilder's `get()` objects into this class' objects.
     *
     * This will allow to process getters and setters and give superpowers to
     * each object.
     *
     * @param Collection $objects Collection of plain objects.
     * @return Collection Collection of this class' objects.
     */
    public static function processCollection(
        Collection $objects
    ) : TableModelCollection {
        return new TableModelCollection($objects, static::class);
    }

    /**
     * Helpers
     * =======
     */
    public static function attributes() : array
    {
        return array_keys(static::$attributes);
    }

    public function instanceAttributes() : array
    {
        return array_merge(static::attributes(), $this->appends);
    }

    public static function foreignKeys() : array
    {
        return array_keys(static::$foreign_keys);
    }

    /*
    public static function foreignKeyObjectAttributes() : array
    {
        return array_map(function($foreign_key) {
            return preg_replace('/_[^_]*$/', '', $foreign_key);
        }, array_keys(static::$foreign_keys));
    }
    */

    public static function guessFK($attribute) : string
    {
        foreach (static::foreignKeys() as $foreign_key) {
            if (preg_match(
                '/^' . $attribute . '_[^_]+$/',
                $foreign_key,
                $matches
            )) {
                return $matches[0];
            }
        }
        throw new UnknownForeignObjectException(static::class, $attribute);
    }

    // Append attribute dynamically, so it can be get and set on a instance
    public function append($attribute) : void
    {
        array_push($this->appends, $attribute);
    }

    /**
     * General helpers
     * ===============
     *
     * This helpers might be useful out of this context too
     */

    protected static function snakeToPascalCase(string $str)
    {
        return str_replace('_', '', ucwords($str, '_'));
    }

    protected static function commaStrRepeat(string $input, int $multiplier)
    {
        if ($multiplier == 0) {
            return "";
        }
        return str_repeat("$input,", $multiplier - 1) . $input;
    }

    protected static function bcAbs(string $value)
    {
        return trim($value, '-');
    }
}
